using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using OpenIddict.Server;
using OpenIddict.Validation;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Web.Common.Security;
using Umbraco.Extensions;

namespace Umbraco.Cms.Api.Common.DependencyInjection;

internal sealed class HideBackOfficeTokensHandler
    : IOpenIddictServerHandler<OpenIddictServerEvents.ApplyTokenResponseContext>,
        IOpenIddictServerHandler<OpenIddictServerEvents.ApplyAuthorizationResponseContext>,
        IOpenIddictServerHandler<OpenIddictServerEvents.ExtractTokenRequestContext>,
        IOpenIddictValidationHandler<OpenIddictValidationEvents.ProcessAuthenticationContext>,
        INotificationHandler<UserLogoutSuccessNotification>
{
    private const string RedactedTokenValue = "[redacted]";
    private const string AccessTokenCookieKey = "__Host-umbAccessToken";
    private const string RefreshTokenCookieKey = "__Host-umbRefreshToken";
    private const string PkceCodeCookieKey = "__Host-umbPkceCode";

    private readonly IHttpContextAccessor _httpContextAccessor;
    private readonly IDataProtectionProvider _dataProtectionProvider;
    private readonly BackOfficeTokenCookieSettings _backOfficeTokenCookieSettings;
    private readonly GlobalSettings _globalSettings;

    public HideBackOfficeTokensHandler(
        IHttpContextAccessor httpContextAccessor,
        IDataProtectionProvider dataProtectionProvider,
        IOptions<BackOfficeTokenCookieSettings> backOfficeTokenCookieSettings,
        IOptions<GlobalSettings> globalSettings)
    {
        _httpContextAccessor = httpContextAccessor;
        _dataProtectionProvider = dataProtectionProvider;
        _backOfficeTokenCookieSettings = backOfficeTokenCookieSettings.Value;
        _globalSettings = globalSettings.Value;
    }

    /// <summary>
    /// This is invoked when tokens (access and refresh tokens) are issued to a client. For the back-office client,
    /// we will intercept the response, write the tokens from the response into HTTP-only cookies, and redact the
    /// tokens from the response, so they are not exposed to the client.
    /// </summary>
    public ValueTask HandleAsync(OpenIddictServerEvents.ApplyTokenResponseContext context)
    {
        if (context.Request?.ClientId is not Constants.OAuthClientIds.BackOffice)
        {
            // Only ever handle the back-office client.
            return ValueTask.CompletedTask;
        }

        HttpContext httpContext = GetHttpContext();

        if (context.Response.AccessToken is not null)
        {
            SetCookie(httpContext, AccessTokenCookieKey, context.Response.AccessToken);
            context.Response.AccessToken = RedactedTokenValue;
        }

        if (context.Response.RefreshToken is not null)
        {
            SetCookie(httpContext, RefreshTokenCookieKey, context.Response.RefreshToken);
            context.Response.RefreshToken = RedactedTokenValue;
        }

        return ValueTask.CompletedTask;
    }

    /// <summary>
    /// This is invoked when a PKCE code is issued to the client. For the back-office client, we will intercept the
    /// response, write the PKCE code from the response into a HTTP-only cookie, and redact the code from the response,
    /// so it's not exposed to the client.
    /// </summary>
    public ValueTask HandleAsync(OpenIddictServerEvents.ApplyAuthorizationResponseContext context)
    {
        if (context.Request?.ClientId is not Constants.OAuthClientIds.BackOffice)
        {
            // Only ever handle the back-office client.
            return ValueTask.CompletedTask;
        }

        if (context.Response.Code is not null)
        {
            SetCookie(GetHttpContext(), PkceCodeCookieKey, context.Response.Code);
            context.Response.Code = RedactedTokenValue;
        }

        return ValueTask.CompletedTask;
    }

    /// <summary>
    /// This is invoked when requesting new tokens.
    /// </summary>
    public ValueTask HandleAsync(OpenIddictServerEvents.ExtractTokenRequestContext context)
    {
        if (context.Request?.ClientId != Constants.OAuthClientIds.BackOffice)
        {
            // Only ever handle the back-office client.
            return ValueTask.CompletedTask;
        }

        // Handle when the PKCE code is being exchanged for an access token.
        if (context.Request.Code == RedactedTokenValue
            && TryGetCookie(PkceCodeCookieKey, out var code))
        {
            context.Request.Code = code;

            // We won't need the PKCE cookie after this, let's remove it.
            RemoveCookie(GetHttpContext(), PkceCodeCookieKey);
        }
        else
        {
            // PCKE codes should always be redacted. If we got here, someone might be trying to pass another PKCE
            // code. For security reasons, explicitly discard the code (if any) to be on the safe side.
            context.Request.Code = null;
        }

        // Handle when a refresh token is being exchanged for a new access token.
        if (context.Request.RefreshToken == RedactedTokenValue
            && TryGetCookie(RefreshTokenCookieKey, out var refreshToken))
        {
            context.Request.RefreshToken = refreshToken;
        }
        else
        {
            // If we got here, either the refresh token was not redacted, or nothing was found in the refresh token cookie.
            // If OpenIddict found a refresh token, it could be an old token that is potentially still valid. For security
            // reasons, we cannot accept that; at this point, we expect the refresh tokens to be explicitly redacted.
            context.Request.RefreshToken = null;
        }

        return ValueTask.CompletedTask;
    }

    /// <summary>
    /// This is invoked when extracting the auth context for a client request.
    /// </summary>
    public ValueTask HandleAsync(OpenIddictValidationEvents.ProcessAuthenticationContext context)
    {
        // For the back-office client, this only happens when an access token is sent to the API.
        if (context.AccessToken != RedactedTokenValue)
        {
            return ValueTask.CompletedTask;
        }

        if (TryGetCookie(AccessTokenCookieKey, out var accessToken))
        {
            context.AccessToken = accessToken;
        }

        return ValueTask.CompletedTask;
    }

    public void Handle(UserLogoutSuccessNotification notification)
    {
        HttpContext? context = _httpContextAccessor.HttpContext;
        if (context is null)
        {
            // For some reason there is no ambient HTTP context, so we can't clean up the cookies.
            // This is OK, because the tokens in the cookies have already been revoked at user sign-out,
            // so the cookie clean-up is mostly cosmetic.
            return;
        }

        context.Response.Cookies.Delete(AccessTokenCookieKey);
        context.Response.Cookies.Delete(RefreshTokenCookieKey);
    }

    private HttpContext GetHttpContext()
        => _httpContextAccessor.GetRequiredHttpContext();

    private void SetCookie(HttpContext httpContext, string key, string value)
    {
        var cookieValue = EncryptionHelper.Encrypt(value, _dataProtectionProvider);

        RemoveCookie(httpContext, key);
        httpContext.Response.Cookies.Append(key, cookieValue, GetCookieOptions(httpContext));
    }

    private void RemoveCookie(HttpContext httpContext, string key)
        => httpContext.Response.Cookies.Delete(key, GetCookieOptions(httpContext));

    private CookieOptions GetCookieOptions(HttpContext httpContext) =>
        new()
        {
            // Prevent the client-side scripts from accessing the cookie.
            HttpOnly = true,

            // Mark the cookie as essential to the application, to enforce it despite any
            // data collection consent options. This aligns with how ASP.NET Core Identity
            // does when writing cookies for cookie authentication.
            IsEssential = true,

            // Cookie path must be root for optimal security.
            Path = "/",

            // For optimal security, the cooke must be secure. However, Umbraco allows for running development
            // environments over HTTP, so we need to take that into account here.
            // Thus, we will make the cookie secure if:
            // - HTTPS is explicitly enabled by config (default for production environments), or
            // - The current request is over HTTPS (meaning the environment supports it regardless of config).
            Secure = _globalSettings.UseHttps || httpContext.Request.IsHttps,

            // SameSite is configurable (see BackOfficeTokenCookieSettings for defaults):
            SameSite = ParseSameSiteMode(_backOfficeTokenCookieSettings.SameSite),
        };

    private bool TryGetCookie(string key, [NotNullWhen(true)] out string? value)
    {
        if (GetHttpContext().Request.Cookies.TryGetValue(key, out var cookieValue))
        {
            value = EncryptionHelper.Decrypt(cookieValue, _dataProtectionProvider);
            return true;
        }

        value = null;
        return false;
    }

    private static SameSiteMode ParseSameSiteMode(string sameSiteMode) =>
        Enum.TryParse(sameSiteMode, ignoreCase: true, out SameSiteMode result)
            ? result
            : throw new ArgumentException($"The provided {nameof(sameSiteMode)} value could not be parsed into as SameSiteMode value.", nameof(sameSiteMode));
}
